Skip to content

feat(ramp): wire onClose for user dismissal (Phase 8)#29919

Merged
saustrie-consensys merged 6 commits into
mainfrom
headless-buy-phase-8-on-close
May 11, 2026
Merged

feat(ramp): wire onClose for user dismissal (Phase 8)#29919
saustrie-consensys merged 6 commits into
mainfrom
headless-buy-phase-8-on-close

Conversation

@saustrie-consensys
Copy link
Copy Markdown
Contributor

@saustrie-consensys saustrie-consensys commented May 8, 2026

Description

This PR closes Phase 8 of the incremental Unified Buy (v2) headless buy plan (app/components/UI/Ramp/headless/PLAN.md): when the user backs out of the headless flow without producing an order, the consumer's onClose callback now fires with { reason: 'user_dismissed' } exactly once per session.

Reason

  • Phase 6 (#29340) wired onClose({ reason: 'completed' }) after onOrderCreated. Phase 7 (#29612) wired failSessiononClose({ reason: 'unknown' }) after onError. The single remaining termination path — user dismissal — left the session alive in the registry and the consumer never received a terminal onClose.
  • Flagged in the Apr 28 2026 progress sync as the open blocker for MetaMask Pay's TransactionPayController (MetaMask/core#8628). TPC's two-step flow (Fiat purchase → Intent transaction) can't sequence step II without a reliable terminal onClose. Production money account ships end of May.

What changed

  • useHeadlessSessionDismissal — new hook in app/components/UI/Ramp/headless/. On unmount (or headlessSessionId change) the cleanup calls closeSession(id, { reason: 'user_dismissed' }) — but only if the session is still in the registry and the navigator's current state no longer contains HEADLESS_HOST. The state inspection is the stack-rebuild guard described below; without it, useTransakRouting's navigation.reset calls would unmount the Host mid-flow and close the session prematurely. closeSession is idempotent on terminal sessions, so Phase 6 / Phase 7 / consumer-cancel paths (which all delete the session before unmount) make the cleanup a no-op.
  • Stack-rebuild guard (fix surfaced during verification). Real-device testing revealed that useTransakRouting.navigateToWebviewModalCallback opens Checkout via navigation.reset({ routes: [HEADLESS_HOST, CHECKOUT] }). React Navigation rebuilds the stack with fresh route keys, which unmounts the original HeadlessHost instance even though logically the user is still inside the headless flow. The first version of useHeadlessSessionDismissal treated that unmount as a dismissal and closed the session — by the time the Transak WebView redirected back ~50s later, getSession(id) returned undefined and the Phase 6 bypass fell through to navigation.reset(...RAMPS_ORDER_DETAILS), breaking the headless contract on every successful buy. The hook now reads navigation.getState() in the unmount cleanup and walks the routes (recursively, for nested navigators); if HEADLESS_HOST is still present, the unmount is a stack rebuild and the close is skipped. If getState() throws (navigator torn down), the cleanup falls through to close — true dismissal is the safe default. The [bypass] + [dismissal-cleanup] diagnostic logs that proved the bug have been removed from the final patch.
  • HeadlessHost — calls the hook. Also fires closeSession({ reason: 'user_dismissed' }) synchronously from handleBack so the close happens at the moment of intent rather than during unmount; the unmount cleanup remains a defense-in-depth fallback for back-gesture / programmatic nav.
  • HeadlessHost.test.tsxDismissal (Phase 8) describe block (back/cancel buttons, mid-flow unmount, Phase-6-completed-before-unmount regression, Phase-7-error-before-unmount regression, mounted-on-already-cancelled). The existing "rejection-after-unmount" test was updated: post-Phase 8, unmount now fires the dismissal close, and the late continueWithQuote rejection still does not produce a second onClose (the .catch re-reads the registry). Suite size: 23 tests.
  • useHeadlessSessionDismissal.test.ts12 hook unit tests at 100% coverage. Eight original tests cover terminal-state idempotency, id changes, undefined/unknown ids. Four new tests under the stack-rebuild guard describe block cover the navigator-state check: HEADLESS_HOST present as a direct route, HEADLESS_HOST present in a nested navigator state, HEADLESS_HOST absent (true dismissal), and getState() throwing (defensive fallback to close).
  • index.ts — barrel-exports the hook so future Phase 5b can reuse it.
  • PLAN.md (Phase 8 edits) — checks off Phase 8; relocates the BuildQuote-dismissal bullet to Phase 10 (since BuildQuote is no longer a headless entry under quote-first); adds a Phase 9 Update (May 2026) section reflecting that MetaMask Pay's TPC needs an imperative awaitOrderTerminalState(orderId) Promise (not just the playground "Refresh" button) and the auto-select-best-provider utility called out in the same Apr 28 sync; restructures Phase 10 to absorb the deferred Phase 5b raw-params start mode and the BuildQuote dismissal that ships with it.
  • PLAN.md (May 6 design-thread follow-ups) — inserts a new Phase 9.5 — HeadlessHost visual treatment sourced from the May 6 2026 design thread. Pedro confirmed the Host must stay mounted (routing landing pad + Phase 7 error surface) but doesn't have to be visible; the consumer (TPC / MMPay) renders the loading UI; back-arrow stays available so Phase 8's dismissal contract continues to apply. Final shape (transparent overlay vs bottom-sheet) pends Lucas's design rec (May 13). Also appends an open question under the Phase 9 Update about whether ramps needs an internal timeout (Barbara, same thread, reply 36) — not blocking for v1, two API shapes worth weighing during Phase 9 implementation.
  • PLAN.md (Design principles section) — new top-level section between "Architecture at a glance" and Phase 1 capturing two cross-cutting API rules: (1) callbacks-only, three terminal events — no intermediate progress callbacks (onAuthStarted, onKycRequired, etc.) so consumers don't couple to ramp flow internals; (2) consumer renders all visible UI — no render-shape props (loadingText, spinnerComponent, etc.). Both rules were implicit in the API as designed but undocumented; making them explicit makes them defensible in PR review and harder to erode by accretion. Phase 9.5's Host strip-down is the implementation side of rule 2.
  • PLAN.md (Phase 10 polish surfaced during verification) — Phase 8's on-device verification turned up two React Navigation warnings and one Phase-7-leak that didn't block this PR's fix but belong on the Phase 10 cleanup list. Added as Goal 3 — Navigation/state cleanups (flatten the startHeadlessBuy nested RampTokenSelection descriptor; move Checkout's onNavigationStateChange function out of route params and into the session registry, since stashing functions in nav params breaks state restore) and Goal 4 — Suppress the global order toast for headless orders (Phase 7 follow-up) (Phase 7's audit caught the in-flow showV2OrderToast call sites but missed the FiatOrders background processor in index.tsx; fix is to stamp orders with a headless: true flag in the bypass paths and short-circuit the toast in processFiatOrder while preserving Redux state + analytics). Each entry has a Risk if left line so the next reviewer can decide priority.

References

  • Stacked on Phase 7: #29612. This PR's base is main (Phase 7 has merged).
  • Continues from Phase 6: #29340. Phase 5: #29338.

Tests

  • yarn jest --watchman=false app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx app/components/UI/Ramp/hooks/useTransakRouting.test.ts76 pass / 0 fail (12 dismissal hook + 23 HeadlessHost + 41 useTransakRouting).
  • New code coverage (useHeadlessSessionDismissal.ts): 100% statements / 100% branches / 100% functions / 100% lines.
  • yarn eslint app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts app/components/UI/Ramp/hooks/useTransakRouting.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx app/components/UI/Ramp/headless/index.ts — clean.
  • yarn prettier --check on all changed files — clean.
  • yarn lint:tsc — clean (exit 0).
  • On-device: single-session headless aggregator buy reproduces the bug pre-fix (lands on RAMPS_ORDER_DETAILS); post-fix, lands back at the playground with onOrderCreated + onClose({ reason: 'completed' }) and no OrderDetails screen.

Changelog

CHANGELOG entry: null

Related issues

Fixes: TRAM-3529

Continuity: #29612 (Phase 7 — structured errors as data). #29340 (Phase 6 — order success callback + stack unwind). #29338 (Phase 5 — Headless Host + quote-first start).

Manual testing steps

Feature: Headless Buy Phase 8 (cancellation + onClose)

  Scenario: Successful headless buy survives the navigation.reset stack rebuild
    Given the app is an internal build and I am signed in
    And I open Settings → Fiat on-ramp → Headless Buy playground
    When I start a headless quote and complete the purchase end-to-end
    Then the playground event log should show `onOrderCreated(orderId)` followed by `onClose({ reason: "completed" })`
    And I should land back on the playground — NOT on the Ramp OrderDetails screen
    And the global "Your purchase of X was successful" toast may still fire (tracked as Phase 10 Goal 4)

  Scenario: User backs out of HeadlessHost mid-flow
    Given I open the Headless Buy playground
    When I start a headless aggregator quote
    And I press the back button on the Host before the order is created
    Then the playground event log should show `onClose({ reason: "user_dismissed" })` exactly once
    And the session should be removed from the registry

  Scenario: User backs out from inside Checkout
    Given I start a headless aggregator quote and reach the Checkout WebView
    When I swipe back to exit the entire ramp stack
    Then the playground should receive `onClose({ reason: "user_dismissed" })` exactly once

  Scenario: Successful order regression — Phase 6 wins, Phase 8 no-ops
    Given I start a headless quote and complete the order
    Then the playground should receive `onOrderCreated(orderId)` followed by `onClose({ reason: "completed" })` — no extra `onClose` from dismissal

  Scenario: Hard error regression — Phase 7 wins, Phase 8 no-ops
    Given I start a headless quote and trigger a quote error (e.g. limit exceeded)
    Then the playground should receive `onError(...)` followed by `onClose({ reason: "unknown" })` — no extra `onClose` from dismissal

  Scenario: Restart cancels previous, no double `onClose`
    Given I have an active headless session A
    When I start a new headless session B before A has terminated
    Then session A should receive `onClose({ reason: "consumer_cancelled" })` exactly once

  Scenario: Non-headless Buy flow is unchanged
    Given I open Wallet → Buy through the regular flow
    When I back out of any screen
    Then the existing Ramp UI surfaces should behave as before

Screenshots/Recordings

Before

N/A — Phase 8 changes callback plumbing only.

After

N/A — no user-facing UI changes.

But here's two videos anyways of happy path and not-happy path (user cancels):

Screen.Recording.2026-05-11.at.4.29.03.PM.mov
Screen.Recording.2026-05-11.at.4.30.55.PM.mov

Pre-merge author checklist

Performance checks (if applicable)

  • I've tested on Android
  • I've tested with a power user scenario
  • I've instrumented key operations with Sentry traces for production performance metrics

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

Note

Medium Risk
Changes headless session lifecycle/termination behavior and adds navigation-state inspection to avoid premature closes during navigation.reset, which could impact checkout flow completion if incorrect. Covered by new unit tests, but touches critical orchestration paths.

Overview
Headless buy now reliably emits a terminal onClose({ reason: 'user_dismissed' }) when the user leaves the headless flow.

Adds useHeadlessSessionDismissal (exported from the headless barrel) and wires it into HeadlessHost so that unmounting the host closes any still-live session, with a stack-rebuild guard that checks navigation.getState() to avoid closing during navigation.reset-driven route key swaps.

Updates HeadlessHost to also call closeSession(..., { reason: 'user_dismissed' }) synchronously from the back/cancel handler, and expands tests to ensure onClose fires exactly once across back/cancel, unmount, late promise rejections, and Phase 6/7 terminal paths. Documentation in PLAN.md is updated to mark Phase 8 complete and capture follow-up roadmap/design notes.

Reviewed by Cursor Bugbot for commit 08b88f5. Bugbot is set up for automated code reviews on this repo. Configure here.

Closes Phase 8 of the Headless Buy plan. When the user backs out of the
headless flow without producing an order, the consumer's onClose
callback now fires with { reason: 'user_dismissed' } so external
consumers (notably MetaMask Pay's TransactionPayController) can detect
dismissal and clean up.

- Add useHeadlessSessionDismissal hook that fires the dismissal close on
  unmount when the session is still in the registry
- Call the hook from HeadlessHost (the only headless entry under
  quote-first) and fire closeSession synchronously from handleBack so
  the close happens at the moment of intent, with the unmount cleanup
  as a defense-in-depth fallback for back-gesture / programmatic nav
- Idempotent: closeSession no-ops on terminal sessions, so Phase 6
  (completed), Phase 7 (failSession), Phase 5 restart and consumer
  cancel paths each fire onClose exactly once before unmount and the
  dismissal cleanup that follows is a no-op
- PLAN.md: check off Phase 8, relocate BuildQuote-dismissal bullet
  (deferred to Phase 10), add Phase 9 Update reflecting MetaMask Pay's
  awaitOrderTerminalState requirement, restructure Phase 10 to absorb
  deferred Phase 5b
@saustrie-consensys saustrie-consensys added the team-money-movement issues related to Money Movement features label May 8, 2026
@github-actions github-actions Bot added the pr-not-ready-for-e2e Skip E2E and block merging. Remove this label once the PR is ready to run the E2E tests. label May 8, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

@saustrie-consensys saustrie-consensys self-assigned this May 8, 2026
@saustrie-consensys saustrie-consensys removed the pr-not-ready-for-e2e Skip E2E and block merging. Remove this label once the PR is ready to run the E2E tests. label May 8, 2026
@github-actions github-actions Bot added the size-M label May 8, 2026
… open question

Captures decisions and open questions from the May 6 2026 design thread
(https://consensys.slack.com/archives/C0AK3NXRM7W/p1778072992397499) on
how MetaMask Pay consumes the headless flow.

- New Phase 9.5: HeadlessHost visual treatment. Pedro confirmed the
  Host must stay mounted (routing landing pad + nativeFlowError
  surface) but doesn't have to be visible. Two shapes evaluated:
  transparent overlay with consumer-rendered spinner (Pedro's pick) or
  bottom-sheet with the Host's own spinner. Final shape pending
  Lucas's design rec (May 13). Phase 8's dismissal contract is
  unaffected — back-press still fires onClose(user_dismissed).
- Phase 9 Update: append the timeout open question Barbara raised
  (reply 36). Two API shapes worth considering during Phase 9
  implementation: timeoutMs on awaitOrderTerminalState, or a
  registry-side per-session timeout that fires
  onError(TIMED_OUT) + onClose. Not blocking for v1.
- Resolve a naming conflict: the auto-select-best-provider utility was
  tentatively listed as "Phase 9.5"; renamed to "follow-up phase" so
  the new visual-treatment phase can own the 9.5 number.
Captures two cross-cutting API rules so future contributors know the
constraints before extending `useHeadlessBuy`:

1. Callbacks-only, three terminal events. No intermediate progress
   callbacks (onAuthStarted / onKycRequired / etc.) — they would
   couple consumers to ramp internals and force them to update on
   every flow change.

2. The consumer renders all visible UI. No render-shape props
   (loadingText / spinnerComponent / etc.). Headless Ramps is a
   behavior provider, not a UI provider — Phase 9.5 implements this
   on the Host side; the API side must stay this shape.

Both principles were implicit in the API as designed but undocumented;
making them explicit makes them defensible in PR review and harder to
erode by accretion.

Section sits between "Architecture at a glance" and Phase 1 so it is
visible to anyone reading PLAN.md top-down.
@github-actions github-actions Bot added size-L and removed size-M labels May 8, 2026
@saustrie-consensys saustrie-consensys marked this pull request as ready for review May 11, 2026 12:18
@saustrie-consensys saustrie-consensys requested a review from a team as a code owner May 11, 2026 12:18
saustrie-consensys and others added 2 commits May 11, 2026 16:56
…uild

Real-device verification of Phase 8 revealed that `useTransakRouting`
opens Checkout via `navigation.reset({ routes: [HEADLESS_HOST, CHECKOUT] })`,
which rebuilds the navigator with fresh route keys and unmounts the
original HeadlessHost instance — even though logically the user is
still in the headless flow. The first `useHeadlessSessionDismissal`
treated that unmount as a dismissal and fired
`closeSession({ reason: 'user_dismissed' })`. By the time the Transak
widget redirected back ~50s later, `getSession(id)` returned undefined
and the Phase 6 bypass fell through to `RAMPS_ORDER_DETAILS`, breaking
the headless contract on every successful buy.

Fix: the unmount cleanup now reads `navigation.getState()` and walks
its routes (recursively, for nested navigators). If HEADLESS_HOST is
still present, the unmount is a stack rebuild and the close is skipped.
If `getState()` throws (navigator torn down), the cleanup falls through
to close — treating a missing navigator as "user left" is the safe
default. The original Phase 8 termination paths (Phase 6 completed,
Phase 7 unknown, consumer cancellation, handleBack) keep working
unchanged because `getSession(id)` already returns undefined by the
time the cleanup runs.

Tests:
- 4 new tests under `stack-rebuild guard` in
  useHeadlessSessionDismissal.test.ts covering: HEADLESS_HOST present
  as direct route, HEADLESS_HOST in nested navigator state, absent
  (true dismissal), and getState throwing. Suite is now 12 tests at
  100% coverage.
- Existing HeadlessHost.test.tsx Dismissal block (23 tests) keeps
  passing because the test mock's missing getState triggers the
  defensive throw branch, which matches the existing "close on
  unmount" assertions.

PLAN.md (Phase 10 polish surfaced during verification): added two
secondary goals so the next reviewer can sequence them.
- Goal 3 — Navigation/state cleanups: flatten the nested
  RampTokenSelection descriptor in `startHeadlessBuy` (3-level
  warning); move Checkout's `onNavigationStateChange` function out
  of route params and into the session registry (state-restore
  failure mode if the app is killed mid-Checkout).
- Goal 4 — Suppress the global order toast for headless orders
  (Phase 7 follow-up): Phase 7 audited the in-flow toast call sites
  but missed `processFiatOrder` in index.tsx, which fires
  `showV2OrderToast` whenever a polled order's state transitions to
  Completed. Fix shape: stamp `headless: true` on the order in the
  three bypass paths; short-circuit the toast in processFiatOrder
  while preserving Redux state + analytics parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

  • Selected E2E tags: SmokeMoney
  • Selected Performance tags: None (no tests recommended)
  • Risk Level: medium
  • AI Confidence: 85%
click to see 🤖 AI reasoning details

E2E Test Selection:
The PR implements Phase 8 of the Headless Ramps architecture — specifically the onClose({ reason: 'user_dismissed' }) callback semantics for the headless buy flow. Changes are:

  1. New useHeadlessSessionDismissal hook: Fires onClose({ reason: 'user_dismissed' }) on HeadlessHost unmount when the session hasn't reached a terminal state. Includes a stack-rebuild guard to avoid false dismissals during navigation.reset.

  2. HeadlessHost.tsx updates: Wires in the new hook and adds synchronous closeSession in handleBack before navigation.goBack().

  3. Barrel export and documentation updates.

All changes are scoped to the Ramp/fiat on-ramp headless flow. The SmokeMoney tag covers ramps buy flows (deeplink-to-buy, unified buy, sell flows, card flows) which are the relevant E2E tests for validating this area. No E2E tests currently cover the headless flow directly, but the ramp smoke tests exercise the broader buy/sell flow infrastructure that this change is part of.

No other tags are needed: this is not a confirmation flow change (no SmokeConfirmations needed), not a swap change (no SmokeSwap), and doesn't touch navigation infrastructure broadly enough to warrant wider coverage. The changes are well-covered by unit tests and the risk is medium since it's a behavioral change to session lifecycle callbacks in a specific flow.

Performance Test Selection:
The changes are purely behavioral — adding a React hook that fires a callback on component unmount and a synchronous function call in a back handler. There are no rendering loops, list components, state management changes, or data loading paths affected. No performance impact is expected.

View GitHub Actions results

@sonarqubecloud
Copy link
Copy Markdown

@saustrie-consensys saustrie-consensys added this pull request to the merge queue May 11, 2026
Merged via the queue into main with commit 5a58835 May 11, 2026
90 of 92 checks passed
@saustrie-consensys saustrie-consensys deleted the headless-buy-phase-8-on-close branch May 11, 2026 15:21
@github-actions github-actions Bot locked and limited conversation to collaborators May 11, 2026
@metamaskbotv2 metamaskbotv2 Bot added the release-7.78.0 Issue or pull request that will be included in release 7.78.0 label May 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release-7.78.0 Issue or pull request that will be included in release 7.78.0 size-L team-money-movement issues related to Money Movement features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants